主题
Go 安全工具选型:先分清库、扫描器和密钥服务
jwt、go-oidc、Casbin 和 Vault 都可能出现在登录系统里,但它们没有在解决同一个问题。JWT 库验证令牌格式和声明,OIDC 客户端处理联合身份的一部分,策略引擎判断权限,密钥服务管理秘密和密码学操作。扫描器则从旁检查代码或制品,不参与每一次业务授权。
本文逐一说明九个工具或工具类别:golang-jwt/jwt、golang.org/x/crypto、Casbin、go-oidc、govulncheck、gosec、Trivy、Vault、云厂商 KMS。选型重点是它负责哪段边界、调用方还欠哪些工作,以及测试结果能说明什么。
先按任务选入口
| 工具 | 放在系统哪里 | 主要入口或产物 | 解决的问题 | 不能替代的工作 |
|---|---|---|---|---|
| golang-jwt/jwt | Go 进程内 | ParseWithClaims、签名方法、验证选项 | JWT 编解码、验签、声明校验 | 登录协议、刷新吊销、对象授权 |
| x/crypto | Go 进程内 | Argon2、bcrypt 等具体包 | 标准库之外的密码学实现 | 参数设计、密钥管理、完整协议 |
| Casbin | 授权执行点 | Model、Policy、Enforce | 按模型计算是否允许 | 认证、可信属性、事务、所有入口覆盖 |
| go-oidc | OIDC 客户端 | Provider、Verifier、RemoteKeySet | 发现、密钥获取、ID Token 验证 | 完整回调事务、PKCE/state/nonce生命周期 |
| govulncheck | CI/检查阶段 | 已知漏洞及可达性结果 | Go 依赖与标准库已知漏洞分析 | 业务逻辑、未知漏洞、全部动态路径 |
| gosec | CI/代码审查 | 规则发现与分析错误 | Go 源码中的危险模式 | 运行时证明、完整人工审计 |
| Trivy | 制品与部署检查 | 漏洞、配置、secret、SBOM 等报告 | 多种制品与组件视角检查 | 精确业务调用链、自动证明漏洞可利用 |
| Vault | 独立服务 | KV、动态凭据、Transit、租约与审计 | 秘密发放和部分密码学服务 | 客户端身份、安全运维、业务授权 |
| 云厂商 KMS | 云控制面/密码学服务 | Key、Encrypt/Decrypt、Sign、Wrap 等 | 托管密钥与受控密码学操作 | 通用秘密存储、全部账号风险、数据重加密 |
同一列比较的是相同维度。不要用“Vault 比 jwt 更安全”这类说法选型,它们可能正好需要一起使用。
正在绘制图示…
查看图源代码
flowchart LR
I["可信身份提供方"] --> O["go-oidc:验证登录身份"]
O --> S["本应用会话与主体"]
J["jwt:验证访问令牌"] --> S
S --> C["Casbin:主体、动作、资源策略"]
C --> B["业务操作"]
X["crypto 与 x/crypto:密码学组件"] --> B
K["Vault / KMS:秘密与密钥能力"] --> X
Q["govulncheck / gosec / Trivy"] -. "检查代码、依赖和制品" .-> B
本文固定了什么,实际验证了什么
查证与运行日期为 2026-10-13,工具链是官方 Go 1.27.2、Linux/amd64。下面是复现版本,不是对未来版本的推荐保证。
| 项目 | 固定版本或来源 | 本文实际状态 |
|---|---|---|
| golang-jwt/jwt/v5 | v5.3.1 | RS256 正常和拒绝路径已运行 |
| golang.org/x/crypto | v0.58.0 | Argon2id 接口已运行 |
| github.com/casbin/casbin/v2 | v2.135.0 | 租户/对象/动作策略与撤销已运行 |
| github.com/coreos/go-oidc/v3 | v3.21.0 | 本地静态公钥 ID Token 验证已运行 |
| govulncheck | v1.9.0,官方漏洞数据库 | 源码扫描成功,含模块级已知问题,详见后文 |
| gosec | v2.29.0,官方固定 tag 构建 | 实际尝试扫描,但因 Go 导出数据格式不兼容失败 |
| Trivy / Vault / KMS | 官方文档查证 | 未安装或连接服务,未做实际扫描或云操作 |
库版本来自 go.mod/go.sum,扫描器模块版本用 go version -m 核验。这里只使用公开测试输入和临时随机密钥,没有真实账户或凭据。
golang-jwt/jwt:库能验哪些东西,哪些要你写出来
jwt.NewWithClaims 加签名方法创建令牌,SignedString 生成紧凑表示;验证时用 ParseWithClaims,给定受信密钥回调和明确选项。最危险的误用之一,是让未验证的 alg 决定可接受算法,或者只解码 payload 就把里面的用户 ID 当成已认证主体。v5.3.1 parser
本例把算法固定为 RS256,预期发行者和受众也来自配置。密钥回调只返回预先生成的测试公钥,并检查自定义令牌类型。这个自定义 profile 不声称符合 RFC 9068 的 at+jwt 标准。
go
jwt.WithValidMethods([]string{"RS256"}),
jwt.WithIssuer(issuer),
jwt.WithAudience("orders-api"),
jwt.WithExpirationRequired(),
jwt.WithIssuedAt(),text
jwt-valid: true
jwt-wrong-audience: false第二张令牌也由正确私钥签名,但发给 other-api,所以必须拒绝。测试还尝试用 HS256 令牌进入 RS256 验证路径。WithExpirationRequired 明确要求 exp 存在;WithIssuedAt 开启签发时间检查,业务必填的 sub/jti/iat/nbf 仍由示例补查。固定版本选项
该库不替你维护刷新令牌、撤销表或账号停用状态。jti 只是标识,除非与服务端状态结合,不会自动防重放。它也不会因为令牌里有 role=admin 就知道角色是否仍有效。密钥选择应绑定预配置发行者,不能根据输入的 jku、x5u 或 kid 随意联网或读文件。
算法名还随规范演进。RFC 9864 已弃用多态 JOSE 标识 EdDSA,新增全指定 Ed25519、Ed448;库是否支持这些标识必须按具体版本确认。本例选择支持明确的 RS256,只验证接口行为,不声称其他算法在本环境中都经过验证。RFC 8725、RFC 9864 §2.2
x/crypto:选择具体包,不给整个模块贴安全标签
golang.org/x/crypto 是一组密码学相关包,不是“导入后自动加密”的框架。常见入口包括 argon2.IDKey、bcrypt 的生成/比较函数,以及某些标准库之外的协议或算法实现。标准库已有合适入口时,先明确为何还需要额外包;使用子包前要核对维护状态。
本例实际派生一个 32 字节结果:
go
dk := argon2.IDKey(password, salt, 2, 19*1024, 1, 32)text
argon2-output-bytes: 32这只说明固定版本接口按所选参数返回了预期长度。随机盐由调用方生成;算法、版本、参数、盐和结果的持久化格式也由应用管理。19 MiB、2 次、并行度 1 对应当前 OWASP 的一组最低起点,生产仍要测试内存、延迟、并发、参数上限和登录限流。argon2 v0.58.0、OWASP Password Storage
同一模块里的另一个包可能不适合新项目。本文扫描就遇到了 x/crypto/openpgp 的模块级告警:官方报告明确它未维护、设计不安全、没有已知修复版本。本例只用 Argon2id,没有导入 openpgp;不能因为模块名字相同就断言 Argon2id 命中了同一个漏洞,也不能因此忽略业务仓库里是否有人用了那个子包。GO-2026-5932
Casbin:把策略从业务代码里分离出来,但不能分离责任
Casbin 的 Model 规定请求、策略、角色关系和匹配逻辑;Policy 是具体规则;Enforcer 把输入代入模型返回允许/拒绝与错误。适合权限规则需要独立维护、审查和测试的项目。规则很少时,清楚的本地函数也可能比引入引擎更容易理解。
本例的请求是 sub, dom, obj, act:主体、租户、带可信租户属性的对象、动作。角色关系也带域,避免把 team-a 的角色无意扩展到 team-b。匹配规则首先比较请求租户与对象真实租户,再检查该域下的角色和具体对象、动作。Casbin 域 RBAC
text
m = r.dom == r.obj.Tenant && g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj.Name == p.obj && r.act == p.act加载一条“team-a 的 reader 可以读取 doc-1”策略,再把 Alice 加入这个域的 reader,实际输出:
text
casbin-read: true
casbin-cross-tenant: false
casbin-delete: false这里 Resource.Tenant 必须来自服务端对象记录,sub/dom 必须来自可信身份与成员关系,不能从请求 JSON 原样抄入。策略匹配通过只说明这些输入满足规则,不证明输入是真的。真实对象所有权、状态和共享关系是否要纳入规则,要根据业务继续补充。Casbin ABAC
没有匹配的 allow 规则就拒绝,这是本例 model 的效果,并非所有自定义 model 天生相同。Enforce 返回错误时不能按“没算出来”放行。动态加载策略还要解决存储 adapter、多副本通知、缓存失效、并发访问和审计;测试只验证本地内存移除角色后立即拒绝,没有测分布式撤销。
go-oidc:验证 ID Token,不会替你完成整次登录
通常先用受信发行者地址创建 Provider,再从 Provider 得到端点和 Verifier;授权码交换通常由 golang.org/x/oauth2 配合完成。go-oidc 能处理发现文档、受信 JWKS 和 ID Token 验证,但不是一个拿来即用的完整账户系统。go-oidc 固定版本
本例刻意不用外部 Discovery 或 JWKS。用 oidc.StaticKeySet 放一个本地 RSA 公钥,创建指定 issuer、ClientID 和算法的 Verifier:
go
oidc.NewVerifier(issuer,
&oidc.StaticKeySet{PublicKeys: []crypto.PublicKey{pub}},
&oidc.Config{ClientID: "web-client",
SupportedSigningAlgs: []string{"RS256"},
Now: func() time.Time { return now }})text
oidc-valid: true
oidc-wrong-nonce: false第二行的拒绝来自应用包装函数,不是声称 Verifier 自动知道浏览器会话保存的 nonce。库返回 IDToken.Nonce,应用还要与本次事务的期望值匹配,并原子消费事务。示例也补查了主体、签发时间、严格到期边界和适用的 azp;v3.21.0 源码明确说明 ClientID 受众检查不等同于验证 authorized party。verify.go
真实回调还需要 PKCE、state/nonce 绑定、回调地址精确匹配、一次性 code、错误处理和安全会话创建。不要为了接通测试关掉 issuer、audience、expiry 或 signature 检查。远程密钥获取要配置超时、限制可信发行者、处理轮换和缓存;未知 key ID 不能变成随意对外请求的能力。OIDC Core 验证要求、RFC 9700
本文没有测真实身份提供方、网络密钥轮换、授权码交换或浏览器回调;仅本地签名互通通过,不等于上线登录流程通过。
三种扫描器:看见的是三种不同材料
| 比较维度 | govulncheck | gosec | Trivy |
|---|---|---|---|
| 核心输入 | Go 源码包、模块与可支持的二进制 | Go 源码和类型/数据流分析结果 | 镜像、文件系统、仓库、SBOM 等目标 |
| 核心依据 | Go 漏洞数据库与调用分析 | 固定版本规则集与分析器 | 漏洞库、组件识别、配置和 secret 规则 |
| 更适合发现 | 已知 Go 漏洞是否影响当前代码 | 不安全 API 用法或危险代码模式 | 系统包/语言依赖、配置和秘密暴露等 |
| 常见误判风险 | 将模块命中直接当成当前函数可利用 | 将模式命中直接当成可利用漏洞 | 将包版本命中当成完整应用风险结论 |
| 常见漏看风险 | 未知漏洞、动态调用和分析边界 | 规则未覆盖、加载失败、复杂语义 | 组件识别与数据库覆盖不足 |
| 结果有效前提 | 加载成功、范围和构建配置正确 | 分析成功、规则与工具链兼容 | 目标正确、扫描器与数据库可用 |
govulncheck:这次真的出现了“模块有问题,但没有调用命中”
对文末完整 Go 模块运行固定 govulncheck v1.9.0 -show verbose ./...,2026-10-13 的实际报告包括:
text
=== Symbol Results ===
No vulnerabilities found.
=== Module Results ===
Vulnerability #1: GO-2026-5932
Module: golang.org/x/crypto
Found in: golang.org/x/crypto@v0.58.0
Fixed in: N/A报告末尾说明:当前代码受影响符号计数为 0,导入包层面为 0,所要求模块层面为 1。官方漏洞条目指向未维护的 openpgp 子包;本例使用 Argon2id,扫描没有识别出受影响调用。Go 漏洞管理
因此不能把上面第一行截出来写成“全部安全”,也不应把模块告警改写成“Argon2id 被证实不安全”。应该保留层次、版本、数据库时间和适用性理由。此次数据库更新时间为 2026-10-08 22:31:09 UTC,退出码为 0;这既展示了工具的范围区分,也说明退出码需要结合工具语义解读。
gosec:本次失败本身就是一个值得保留的结果
gosec 的规则包括检查不安全 TLS 配置等内容。实验准备了一个永不被调用的函数,唯一用途是静态扫描输入:
go
func NeverUseThisConfig() *tls.Config {
return &tls.Config{InsecureSkipVerify: true}
}这里没有建立任何连接,也没有改动系统配置。按固定版本规则目录,本来是用来检验 G402 的夹具。gosec v2.29.0 规则
但本次官方 tag 构建的 gosec v2.29.0,在 Go 1.27.2 下加载 crypto/tls 时失败,实际错误关键部分为:
text
export data version 5 is greater than maximum supported version 4JSON 报告的 Stats.found 是 0,Golang errors 非空,进程退出码 1。这次没有得到可信的 G402 检查结果。 若流水线只读“发现数 0”,就会把根本没分析成功的扫描误判成通过。
本例保留失败,不私改扫描器依赖后仍声称是官方版本。实际项目应选择明确兼容目标工具链的发布版本,在升级流水线前用已知好/坏夹具做回归,并同时检查加载错误、扫描覆盖、退出码和发现数。本次 gosec -version 打印 dev,因为 go install 构建没有注入发布元数据;模块版本依据 go version -m 中的 github.com/securego/gosec/v2 v2.29.0 核验。
规则发现需要人工结合上下文确认。合理抑制应带规则 ID、具体理由和复查条件,不能全仓库关闭一类告警。工具还带可选 AI 自动修复入口;本文没有启用它,没有把源码发送给外部 AI 服务。gosec 项目说明
Trivy:把检查延伸到最终镜像和部署材料
Trivy 可以针对镜像和文件系统执行相应扫描。漏洞扫描识别组件与受影响版本;misconfiguration 检查部署配置;secret 扫描按规则寻找疑似秘密;SBOM 功能帮助记录和再次扫描组件清单。应显式选择任务需要的扫描器,而不是只记住一个命令名字。Trivy 官方指南
扫描镜像时优先使用最终部署的 digest,记录操作系统、软件源、工具版本和数据库更新时间。发行版可能回移植修复,不能只拿上游版本字符串手工判断;同一个 CVE 在不同供应商源中的严重性与修复状态也可能不同。Trivy 漏洞扫描说明
secret 扫描能发现一些已知格式和模式,不能证明任何秘密都不存在;漏报、误报和已删除历史层都要按目标范围判断。发现真实 secret 后应撤销或轮换,删除一行文本不是消除暴露。报告本身也可能含敏感片段,应限制访问。Trivy secret scanner
本文只查证文档,未安装 Trivy,也没有目标镜像。因此文末给的是待验证命令形态,不提供伪造扫描输出、兼容版本或性能比较。
Vault:存秘密、发凭据、做密码学操作,是不同引擎
Vault 的 KV 适合保存版本化秘密;数据库 secrets engine 可以按配置生成或管理数据库凭据;Transit 提供加解密、签名等操作。选择引擎时先问应用究竟需要拿到秘密文本,还是只需要一次解密/签名能力,后者不一定要把长期密钥材料交给应用。Vault 文档、数据库引擎、Transit
正在绘制图示…
查看图源代码
sequenceDiagram
participant A as 工作负载
participant V as Vault
participant D as 目标数据库或数据服务
A->>V: 用已配置工作负载身份认证
V->>V: 按策略判断允许的路径和能力
V-->>A: 有租期的凭据或允许的密码学结果
A->>D: 使用最小权限凭据处理请求
A->>V: 按策略续租或停止使用
V->>D: 适用时撤销动态凭据
有租约不代表客户端可以忘记生命周期。应用要处理续租失败、过期、撤销和连接池中已建立连接的行为;不同后端的撤销效果也要实测。不要把 Vault 的 root token 放进应用来换取“集成简单”,也不要给所有工作负载读全路径权限。租约与撤销、策略
自建 Vault 增加了高可用、存储、备份、初始化与解封、升级和审计责任。托管方式会改变运维分工,但不会自动修复过宽策略。本文没有创建 Vault 实例、生成 token、接入数据库或测试租约;这些是选型时必须补的验证。
云厂商 KMS:少分发原始密钥,仍要限制谁能调用
AWS KMS、Google Cloud KMS、Azure Key Vault/Managed HSM 等服务有各自的资源、权限、算法与配额。它们不能仅因为都叫“密钥服务”就按同一 API 或同一轮换语义使用。KMS 通常管理密钥并执行受控密码学操作;任意数据库密码、第三方 token 的保存与自动轮换,可能还需要专门的 Secret 管理服务。
| 选型问题 | 需要核对的内容 | 忽略后的后果 |
|---|---|---|
| 应用需要什么能力 | 加解密、签名、MAC、封装,算法与数据量限制 | 选到不能满足协议的密钥类型 |
| 谁能使用密钥 | 工作负载身份、IAM/策略、加密上下文 | 无原始密钥也能通过接口大量解密 |
| 数据在哪里 | 区域、复制、跨区恢复、驻留约束 | 故障时无法解密或违反数据边界 |
| 轮换意味着什么 | 新版本、旧版本可用性、应用如何切换 | 以为轮换后历史数据已重加密 |
| 网络或额度不足怎么办 | 延迟、配额、重试、缓存与可用性 | 把 KMS 故障放大成全站阻塞 |
| 如何调查调用 | 审计记录、调用主体、key ID、异常告警 | 无法确认谁用了密钥 |
信封加密通常用数据密钥 DEK 加密业务内容,用 KMS 的密钥加密密钥 KEK 包装 DEK。这样大量数据处理可以在应用内完成,KMS 负责控制解开数据密钥的能力;应用内短时存在的明文 DEK 仍需要保护。Google Cloud 信封加密、AWS KMS 概览、Azure Key Vault 概览
轮换不等于历史数据自动迁移。例如 AWS 官方明确说明 KMS key rotation 不会轮换其生成的数据密钥,也不会重加密已保护的数据。Google Cloud 的轮换也需要应用按版本与用途设计后续操作。若密钥已泄漏,仅开启未来轮换不一定减轻已泄漏数据密钥的风险。AWS 轮换说明、Google Cloud 轮换
本文没有连接云账户,没有生成或传递真实凭据,也没有实测任何供应商的吞吐、延迟、费用或合规等级。选型时这些必须按区域、密钥类型和真实负载核实,不能从 SDK 方法名推出结论。
把工具组合成可以失败的检查链
库层测试要包含正常输入和拒绝路径;扫描层要分别识别“没有发现”“存在告警”和“根本没完成”;服务层要验证权限、轮换、故障和恢复。先规定每层失败时发生什么,再决定 CI 是否阻断、何时允许有期限的例外。
本文的两个扫描结果恰好说明这个区分:govulncheck 成功分析并报告一个未被导入调用的模块级问题;gosec 则连类型数据都没读成功。它们都出现过“0”这个数字,含义完全不同。选择工具时,能解释输出和局限,比把工具列表列长更重要。
依据与验证边界
- 库:jwt v5.3.1 的 parser/选项,x/crypto v0.58.0 argon2,Casbin v2.135.0 及官方域模型文档,go-oidc v3.21.0 verify.go/jwks.go;完整依赖校验值见文末 go.sum。
- 协议:RFC 8725、9864、9700、OIDC Core;本文的访问令牌 profile 是本地教学约定,不声称通用标准兼容。
- 扫描:govulncheck v1.9.0、官方 GO-2026-5932;gosec v2.29.0 固定规则与实际加载错误;Trivy 官方扫描器/目标文档,未实测。
- 密钥服务:Vault 引擎、策略和租约官方文档;AWS、Google Cloud、Azure 官方密钥服务与轮换文档,未接入真实服务。
- 2026-10-13,Go 1.27.2、Linux/amd64,实际通过
go run .、go test -race -count=1 ./...、go vet ./...、go mod verify。测试覆盖 JWT 错受众/错算法、OIDC 错受众/到期/到期等于当前时刻/错 nonce、OIDC 错签名/发行者、缺 sub/iat、未来 iat、空期望 nonce、多受众与 azp,以及 JWT profile/issuer/必填声明、Casbin 其他用户/跨租户/错误对象/未授予动作/撤销。 govulncheck -show verbose ./...成功,符号和导入包层面无发现,模块层面 1 项;DB 更新于 2026-10-08 22:31:09 UTC。gosec 退出码 1 且类型加载失败,不视为扫描通过。未启用任何扫描器的外部 AI 修复功能。- 未做真实 OAuth/OIDC、远程 JWKS 轮换、浏览器、分布式策略、Trivy 镜像、Vault、KMS 或生产风险验证。
完整复现代码与配置
下列文件组成同一个模块,scanfixture/fixture.go 放入对应子目录。该函数只给静态扫描器看,程序和测试从不调用它。临时 RSA 私钥每次生成、不写入文件;所有密码、nonce、ID 与域名都为公开夹具。
go.mod
go.mod
module example.com/security07
go 1.27.2
require (
github.com/casbin/casbin/v2 v2.135.0
github.com/coreos/go-oidc/v3 v3.21.0
github.com/golang-jwt/jwt/v5 v5.3.1
golang.org/x/crypto v0.58.0
)
require (
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
github.com/casbin/govaluate v1.3.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/uuid v1.6.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.49.0 // indirect
)go.sum
text
github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=main.go
代码块 · 133 行 · 点击收起 / 展开
go
package main
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/subtle"
_ "embed"
"errors"
"fmt"
"github.com/casbin/casbin/v2"
"github.com/casbin/casbin/v2/model"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/argon2"
"time"
)
//go:embed model.conf
var modelText string
var now = time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
const issuer = "https://issuer.example"
const publicNonce = "public-test-nonce-bound-to-one-transaction"
func token(key *rsa.PrivateKey, aud, nonce string, expires time.Time) string {
c := jwt.MapClaims{"iss": issuer, "sub": "alice", "aud": aud, "iat": now.Add(-time.Second).Unix(), "nbf": now.Add(-time.Second).Unix(), "exp": expires.Unix(), "jti": "public-test-id"}
typ := "demo-access+jwt"
if nonce != "" {
c["nonce"] = nonce
typ = "JWT"
}
t := jwt.NewWithClaims(jwt.SigningMethodRS256, c)
t.Header["typ"] = typ
raw, err := t.SignedString(key)
must(err)
return raw
}
func checkJWT(raw string, pub *rsa.PublicKey) error {
c := new(jwt.RegisteredClaims)
parsed, err := jwt.ParseWithClaims(raw, c, func(t *jwt.Token) (any, error) {
if t.Header["typ"] != "demo-access+jwt" {
return nil, errors.New("profile")
}
return pub, nil
}, jwt.WithValidMethods([]string{"RS256"}), jwt.WithIssuer(issuer), jwt.WithAudience("orders-api"), jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithTimeFunc(func() time.Time { return now }))
if err != nil {
return err
}
if !parsed.Valid || c.Subject == "" || c.ID == "" || c.IssuedAt == nil || c.NotBefore == nil {
return errors.New("claims")
}
return nil
}
func verifier(pub *rsa.PublicKey) *oidc.IDTokenVerifier {
return oidc.NewVerifier(issuer, &oidc.StaticKeySet{PublicKeys: []crypto.PublicKey{pub}}, &oidc.Config{ClientID: "web-client", SupportedSigningAlgs: []string{"RS256"}, Now: func() time.Time { return now }})
}
func checkOIDC(v *oidc.IDTokenVerifier, raw, wantNonce string) error {
id, err := v.Verify(context.Background(), raw)
if err != nil {
return err
}
if wantNonce == "" || subtle.ConstantTimeCompare([]byte(id.Nonce), []byte(wantNonce)) != 1 {
return errors.New("nonce")
}
if id.Subject == "" || id.IssuedAt.IsZero() || id.IssuedAt.After(now.Add(time.Minute)) || !now.Before(id.Expiry) {
return errors.New("application time/subject")
}
var extra struct {
AZP string `json:"azp"`
}
if err = id.Claims(&extra); err != nil {
return err
}
if len(id.Audience) > 1 && extra.AZP != "web-client" || extra.AZP != "" && extra.AZP != "web-client" {
return errors.New("azp")
}
// A production callback must also atomically consume its transaction state.
return nil
}
type Resource struct{ Name, Tenant string }
func enforcer() (*casbin.Enforcer, error) {
m, err := model.NewModelFromString(modelText)
if err != nil {
return nil, err
}
e, err := casbin.NewEnforcer(m)
if err != nil {
return nil, err
}
if _, err = e.AddPolicy("reader", "team-a", "doc-1", "read"); err != nil {
return nil, err
}
if _, err = e.AddGroupingPolicy("alice", "reader", "team-a"); err != nil {
return nil, err
}
return e, nil
}
func main() {
key, err := rsa.GenerateKey(rand.Reader, 2048)
must(err)
pub := &key.PublicKey
fmt.Println("jwt-valid:", checkJWT(token(key, "orders-api", "", now.Add(time.Minute)), pub) == nil)
fmt.Println("jwt-wrong-audience:", checkJWT(token(key, "other-api", "", now.Add(time.Minute)), pub) == nil)
v := verifier(pub)
raw := token(key, "web-client", publicNonce, now.Add(time.Minute))
fmt.Println("oidc-valid:", checkOIDC(v, raw, publicNonce) == nil)
fmt.Println("oidc-wrong-nonce:", checkOIDC(v, raw, "other-transaction") == nil)
e, err := enforcer()
must(err)
ok, err := e.Enforce("alice", "team-a", Resource{"doc-1", "team-a"}, "read")
must(err)
fmt.Println("casbin-read:", ok)
ok, err = e.Enforce("alice", "team-a", Resource{"doc-1", "team-b"}, "read")
must(err)
fmt.Println("casbin-cross-tenant:", ok)
ok, err = e.Enforce("alice", "team-a", Resource{"doc-1", "team-a"}, "delete")
must(err)
fmt.Println("casbin-delete:", ok)
salt := make([]byte, 16)
_, err = rand.Read(salt)
must(err)
dk := argon2.IDKey([]byte("public test password"), salt, 2, 19*1024, 1, 32)
fmt.Println("argon2-output-bytes:", len(dk))
}
func must(err error) {
if err != nil {
panic(err)
}
}main_test.go
代码块 · 154 行 · 点击收起 / 展开
go
package main
import (
"crypto/rand"
"crypto/rsa"
"github.com/golang-jwt/jwt/v5"
"testing"
"time"
)
func TestJWTAndOIDC(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
pub := &key.PublicKey
if checkJWT(token(key, "orders-api", "", now.Add(time.Minute)), pub) != nil {
t.Fatal("valid jwt")
}
if checkJWT(token(key, "other-api", "", now.Add(time.Minute)), pub) == nil {
t.Fatal("wrong audience")
}
h := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"iss": issuer, "aud": "orders-api", "exp": now.Add(time.Minute).Unix()})
raw, err := h.SignedString([]byte("PUBLIC TEST KEY, NOT A SECRET"))
if err != nil {
t.Fatal(err)
}
if checkJWT(raw, pub) == nil {
t.Fatal("wrong algorithm")
}
v := verifier(pub)
raw = token(key, "web-client", publicNonce, now.Add(time.Minute))
if err = checkOIDC(v, raw, publicNonce); err != nil {
t.Fatal(err)
}
for name, raw := range map[string]string{"wrong-aud": token(key, "other-client", publicNonce, now.Add(time.Minute)), "expired": token(key, "web-client", publicNonce, now.Add(-time.Second)), "expiry-equal": token(key, "web-client", publicNonce, now)} {
if checkOIDC(v, raw, publicNonce) == nil {
t.Fatal(name)
}
}
if checkOIDC(v, raw, "wrong") == nil {
t.Fatal("nonce mismatch")
}
}
func TestPolicy(t *testing.T) {
e, err := enforcer()
if err != nil {
t.Fatal(err)
}
for _, tt := range []struct {
user, dom string
r Resource
action string
want bool
}{{"alice", "team-a", Resource{"doc-1", "team-a"}, "read", true}, {"bob", "team-a", Resource{"doc-1", "team-a"}, "read", false}, {"alice", "team-a", Resource{"doc-1", "team-b"}, "read", false}, {"alice", "team-b", Resource{"doc-1", "team-b"}, "read", false}, {"alice", "team-a", Resource{"doc-1", "team-a"}, "delete", false}} {
got, err := e.Enforce(tt.user, tt.dom, tt.r, tt.action)
if err != nil || got != tt.want {
t.Fatal(tt, got, err)
}
}
if _, err = e.RemoveGroupingPolicy("alice", "reader", "team-a"); err != nil {
t.Fatal(err)
}
if ok, err := e.Enforce("alice", "team-a", Resource{"doc-1", "team-a"}, "read"); err != nil || ok {
t.Fatal("revocation", ok, err)
}
}
func TestOIDCApplicationBoundaries(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
v := verifier(&key.PublicKey)
base := func() jwt.MapClaims {
return jwt.MapClaims{"iss": issuer, "sub": "alice", "aud": "web-client", "iat": now.Add(-time.Second).Unix(), "exp": now.Add(3 * time.Minute).Unix(), "nonce": publicNonce}
}
sign := func(c jwt.MapClaims, k *rsa.PrivateKey) string {
raw, err := jwt.NewWithClaims(jwt.SigningMethodRS256, c).SignedString(k)
if err != nil {
t.Fatal(err)
}
return raw
}
cases := []struct {
name string
mutate func(jwt.MapClaims)
want bool
}{
{"wrong-issuer", func(c jwt.MapClaims) { c["iss"] = "https://other.example" }, false},
{"missing-sub", func(c jwt.MapClaims) { delete(c, "sub") }, false},
{"missing-iat", func(c jwt.MapClaims) { delete(c, "iat") }, false},
{"future-iat", func(c jwt.MapClaims) { c["iat"] = now.Add(2 * time.Minute).Unix() }, false},
{"multi-aud-no-azp", func(c jwt.MapClaims) { c["aud"] = []string{"web-client", "other"} }, false},
{"multi-aud-wrong-azp", func(c jwt.MapClaims) { c["aud"] = []string{"web-client", "other"}; c["azp"] = "other" }, false},
{"multi-aud-correct-azp", func(c jwt.MapClaims) { c["aud"] = []string{"web-client", "other"}; c["azp"] = "web-client" }, true},
{"single-aud-wrong-azp", func(c jwt.MapClaims) { c["azp"] = "other" }, false},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
c := base()
tt.mutate(c)
err := checkOIDC(v, sign(c, key), publicNonce)
if (err == nil) != tt.want {
t.Fatal(err)
}
})
}
if checkOIDC(v, sign(base(), key), "") == nil {
t.Fatal("empty expected nonce")
}
other, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
if checkOIDC(v, sign(base(), other), publicNonce) == nil {
t.Fatal("wrong signing key")
}
}
func TestJWTProfileAndClaims(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"typ", "issuer", "expired", "sub", "jti", "iat", "nbf", "exp"} {
c := jwt.MapClaims{"iss": issuer, "sub": "alice", "aud": "orders-api", "iat": now.Add(-time.Second).Unix(), "nbf": now.Add(-time.Second).Unix(), "exp": now.Add(time.Minute).Unix(), "jti": "test-id"}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, c)
token.Header["typ"] = "demo-access+jwt"
switch name {
case "typ":
token.Header["typ"] = "JWT"
case "issuer":
c["iss"] = "https://other.example"
case "expired":
c["exp"] = now.Add(-time.Second).Unix()
default:
delete(c, name)
}
raw, err := token.SignedString(key)
if err != nil {
t.Fatal(err)
}
if checkJWT(raw, &key.PublicKey) == nil {
t.Fatal("accepted", name)
}
}
e, err := enforcer()
if err != nil {
t.Fatal(err)
}
if ok, err := e.Enforce("alice", "team-a", Resource{"doc-2", "team-a"}, "read"); err != nil || ok {
t.Fatal("wrong object", ok, err)
}
}model.conf
text
[request_definition]
r = sub, dom, obj, act
[policy_definition]
p = sub, dom, obj, act
[role_definition]
g = _, _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.dom == r.obj.Tenant && g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj.Name == p.obj && r.act == p.actscanfixture/fixture.go
go
// Package scanfixture is deliberately unsafe STATIC INPUT for a scanner.
// It does not dial, serve, or change any system security setting.
package scanfixture
import "crypto/tls"
func NeverUseThisConfig() *tls.Config {
return &tls.Config{InsecureSkipVerify: true}
}运行命令
sh
go mod download
go run .
go test -race -count=1 ./...
go vet ./...本次扫描的复现命令
以下安装到项目内 .tools,没有把二进制写入系统目录。默认使用官方 Go 模块代理/校验配置;若网络受限,应使用已核验的本地缓存,不能把失败当作通过。
sh
GOBIN="$PWD/.tools" go install golang.org/x/vuln/cmd/govulncheck@v1.9.0
GOBIN="$PWD/.tools" go install github.com/securego/gosec/v2/cmd/gosec@v2.29.0
go version -m .tools/govulncheck
go version -m .tools/gosec
.tools/govulncheck -version
.tools/govulncheck -show verbose ./...
# 本文 Go 1.27.2 下这一步实际返回 1,JSON 中有类型加载错误。
# 不加 -no-fail,不隐藏失败,也不启用 AI 自动修复参数。
.tools/gosec -include=G402 -fmt=json -out=gosec.json ./scanfixtureTrivy 命令形态(本文未运行)
须先按官方文档安装适合当前环境的版本,记录 trivy --version 与数据库状态。IMAGE_REF 应是已核验的最终镜像 digest,以下命令并非本篇实测结果。
sh
trivy --version
trivy fs --scanners vuln,misconfig,secret .
: "${IMAGE_REF:?set a verified image digest}"
trivy image --scanners vuln,secret "$IMAGE_REF"
trivy image --format cyclonedx --output sbom.cdx.json "$IMAGE_REF"
trivy sbom sbom.cdx.json